React Query

프론트의 역할?

제 개인적인 의견이지만, 누군가가 프론트의 중요한 역할이 무엇이냐고 물어본다면 가장 심플하게 데이터를 잘 받아와, 유저에게 잘 보여주는 것 이라고 대답할것입니다.

물론 그것이 전부는 아니겠지만, 일반적인 웹&앱 서비스에서 가장 많은 부분을 차지하는것은 데이터를 보여주는 일입니다.

그래서 우리는 잘 보여주기 위해 리액트나 뷰, 스벨트 등의 프레임워크를 사용하곤 합니다. DOM을 더 쉽고 간단하게 컨트롤해서 유저에게 더 좋은 UI를 보여주기 위함이죠.

하지만 그만큼 중요한것이 데이터를 잘 받아오는 일입니다. 아마 fetch 이벤트나 대부분의 경우 axios를 사용하여 백엔드와 통신을 할 것입니다. 그리고 대부분의 경우 내부 state 내지 전역상태관리 라이브러리를 이용하여 백엔드에서 받은 데이터를 보여줄 것입니다.

이 과정을 좀 더 쉽고 간단하게 바꿔줄 리액트 쿼리에 대해 알아보겠습니다.


리액트 쿼리

ex_screenshot

대부분 기존 상태관리 라이브러리는 내부 state관리에는 용이하나, 서버 상태관리에는 어려움이 있었습니다.

클라이언트에서 관리하는 서버상태

  • 유저가 소유하거나 통제할수 없는 원격의 위치되어 있습니다.
  • 읽고, 쓰기 위한 비동기 API가 필요합니다.
  • 공공소유권으로 유저의 의사와 상관없이 변할수 있습니다.
  • 금방 “오래된” 데이터가 될 수 있습니다.

해당 문제가 일으키는 더 많은 문제들

  • 캐싱
  • 동일한 데이터에 대한 여러 요청을 단일 요청으로 중복 제거
  • 오래된 데이터 업데이트
  • 데이터가 오래된 시점 알기
  • 신속한 데이터 반영
  • 성능 최적화
  • 서버상태 메모리 및 가비지 관리
  • 구조적 공유로 쿼리 결과 메모하기

이러한 문제들을 해결하기 위해 리액트 쿼리가 개발되었습니다.


라이프사이클과 기본 개념

기본문법

import {
  useQuery,
  useMutation,
  useQueryClient,
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query'
// 서버와 통신하기 위한 fetch 및 axios api 모듈
import { getTodos, postTodo } from '../my-api'

// 쿼리 클라이언트를 생성합니다.
const queryClient = new QueryClient()

function App() {
  return (
    // 쿼리 프로바이더로 APP을 감싸줍니다.
    // 해당 context 는 비동기를 처리하는 background 계층이 됩니다.
    <QueryClientProvider client={queryClient}>
      <Todos />
    </QueryClientProvider>
  )
}

function Todos() {
  // APP내에서 쿼리클라이언트를 사용할수 있습니다.
  const queryClient = useQueryClient()

  // userQuert = R
  const { isLoading, isError, data, error } = useQuery({
    queryKey: ['todos'],
    queryFn: getTodos,
  })

  // useMutations = C,U,D
  const mutation = useMutation({
    mutationFn: postTodo,
    onSuccess: () => {
      // 객체가 성공한다면..
      queryClient.invalidateQueries({ queryKey: ['todos'] })
    },
  })

  return (
    <div>
      <ul>
        {data?.map(todo => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>

      <button
        onClick={() => {
          mutation.mutate({
            id: Date.now(),
            title: 'Do Laundry',
          })
        }}
      >
        Add Todo
      </button>
    </div>
  )
}

라이프 사이클

리액트 쿼리로 가져온 쿼리데이터는 다음과 같은 라이프 사이클을 가지고 있습니다.

fetching => fresh => stale => inactive => delete

각각에 대한 상세내용은 다음과 같습니다.

  • fetching : 데이터 요청 상태입니다.
  • fresh : 갱신될 필요가 없는 신선한 상태입니다.
  • stale : 데이터가 갱신될 필요가 있는 상태입니다. fresh 상테에서 넘어오기까지 기본값은 0입니다
  • inactive : 사용하지 않는 상태이며, 일정시간 이후 가비지 컬렉터가 캐시에서 제거합니다. 기본값은 5분입니다.
  • delete : 데이터가 제거된 상태입니다.

useQuery

const { isLoading, data, error } = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodoList,
})
  • 서버데이터를 가져오며, 읽는것에 특화되어 있습니다.
  • 첫번째 인자로 unique key, 두번째 인자로 프로미스 기반의 비동기 함수를 받습니다.
  • 각각의 status code와 관련된 상태를 가지고 있으며, 비동기 작업이 끝난후 업데이트 합니다.
  • isLoading : 데이터의 로딩중인 status 입니다.
  • data : 요청이 성공한 경우 리턴받는 값입니다.
  • error : 요청이 실패한 경우 리턴받는 값입니다.

queryFn

useQuery(['todos', todoId], () => fetchTodoById(todoId))

// 이런식으로도 표현 가능하다
useQuery(['todos', todoId], async () => {
  const data = await fetchTodoById(todoId)
  return data
})
  • useQuery두번째 인자의 비동기 함수입니다.
  • 함수를 직접 정의할수 있습니다.

useMutation

const mutation = useMutation(newTodo => axios.post('/todos', newTodo))

// setQueryData를 통한 데이터 갱신
export const addTodos = () => {
  const queryClient = useQueryClient()

  return useMutation(fetchAddSuperHero, {
    onSuccess: data => {
      queryClient.setQueryData('todos', prevData => ({
        ...prevData,
        data: [...prevData.data, data.data],
      }))
    },
  })
}
  • mutation으로 서버데이터를 패칭합니다.
  • 주로 post,patch,delete 작업에 사용합니다.
  • 인자로는 비동기 함수를 받으며, 해당 작업이 끝난 이후 setQueryData,invalidateQueries 를 통해 쿼리키를 갱신하여 해당 쿼리키에 쿼리데이터를 stale한 상태로 변경합니다.
  • 단 setQueryData를 사용할 경우 data의 불변성을 지켜줘야 하기에 immer같은 라이브러리 사용을 권장합니다.

queryKey

  • 리액트 쿼리에서 상당히 중요한 개념인 쿼리키입니다.
  • 인자로 넘겨줬던 unique key는 쿼리키로 사용되어 쿼리캐싱을 관리합니다.
  • 쿼리키는 해시되기 때문에 다음과 같은 경우 주의가 필요합니다.
// 다음 쿼리들의 쿼리키는 모두 동일합니다.
useQuery({ queryKey: ['todos', { status, page }], ... })
useQuery({ queryKey: ['todos', { page, status }], ...})
useQuery({ queryKey: ['todos', { page, status, other: undefined }], ... })

// 다음 쿼리들의 쿼리키는 모두 동일하지 않습니다.
useQuery({ queryKey: ['todos', status, page], ... })
useQuery({ queryKey: ['todos', page, status], ...})
useQuery({ queryKey: ['todos', undefined, page, status], ...})
  • 리액트 쿼리 v4부터는 모든 쿼리키는 배열형태로만 제공됩니다.
  • 기본적으로 같은 쿼리키를 공유하는 쿼리는 특정 조건이 아닌 경우 다시 쿼리가 마운트 되어도 리패칭 되지 않습니다.

StaleTime

기본적으로 캐싱된 쿼리는 상태가 stale하지 않으면 리패칭을 진행하지 않습니다. 쿼리의 상태가 stale로 변화하는 시간이 StaleTime이며 default값은 0입니다.

stale한 쿼리는 다음의 경우 리패칭을 시도합니다.

  • stlae 쿼리 인스턴스가 마운트되었을 때

    • refetchOnMount 해당 옵션으로 조절가능
  • 브라우저 윈도우가 다시 포커스되었을 때 (탭이나 윈도우 이동)

    • refetchOnWindowFocus 해당옵션으로 조절가능
  • 네트워크가 다시 연결되었을 때

    • refetchOnReconnect 해당 옵션으로 조절가능
  • refetchInterval 옵션이 있을 때

    • 해당옵션으로 폴링이 가능

Parallel Queries

하나의 컴포넌트에서 2개이상의 쿼리를 실행시킬 경우 특별한 경우가 아니라면 병렬(공식문서에서 병렬이라 칭함)적으로 실행될 것입니다.

function App () {
  // 병렬로 실행되는 쿼리들
  const usersQuery = useQuery({ queryKey: ['users'], queryFn: fetchUsers })
  const todoQuery = useQuery({ queryKey: ['teams'], queryFn: fetchTodo })
  ...
}

// useQueries를 사용할수도 있습니다.
const res = useQueries([
    {
        queryKey: ['users'],
        queryFn: () => {},
    },
    {
        queryKey: ['teams'],
        queryFn: () => {},
    }
]);

Dependent Queries

비동기함수를 체이닝하여 선,후행으로 사용해야 할 경우가 있을것입니다. 그럴 경우에 사용하는것이 종속쿼리입니다.

// 유저 정보를 가져옵니다
const { data: user } = useQuery({
  queryKey: ['user'],
  queryFn: getUser,
})

const userId = user?.id

// 종속쿼리 user쿼리가 요청에 성공하여 userId가 반환될 경우 실행됩니다.
// 이 경우 promise.all과 같이 하나의 배열에 각 객체값이 들어옵니다.
const { status, fetchStatus, data: todos } = useQuery({
  queryKey: ['todos', userId],
  queryFn: getTodos,
  // 유저아이디가 있을경우 해당 속성이 변경됩니다
  enabled: !!userId,
})

QueryCache

전체 쿼리 클라이언트에 대한 성공/실패 분기처리를 할 수 있습니다.

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (error, query) => {
      console.log(error, query);
      if (query.state.data !== undefined) {
        toast.error(`에러가 났어요!!: ${error.message}`);
      },
    },
    onSuccess: data => {
      console.log(data)
    }
  })
});

option

  • enabled
    false로 설정하면 쿼리가 자동으로 실행되지 않습니다.
  • retry
    쿼리가 실패한 경우에 대한 재시도 횟수
  • staleTime
    데이터가 stale state로 변경되는 시간 (Infinity로 설정하면 stale state로 변경되지 않습니다)
  • cacheTime
    inactive state의 캐시 데이터가 메모리에 남아있는 시간
  • refetchInterval
    설정한 시간(밀리초)에 따라 주기적으로 fetching 실행.
  • refetchOnWindowFocus
    창에 포커스가 된 경우에 대한 refetch 여부.
  • sinitialData
    쿼리의 초기값 설정

VS Reudx

우선적으로 리액트 쿼리는 전역 상태관리를 위한 라이브러리가 아닌점을 기본개념으로 가져가야 합니다. 대부분 많은 경우 Redux와 같은 전역 상태관리 라이브러리와 많이 비교하지만, 실질적으로는 개념이 다르며 동시에 사용하는 경우도 있습니다.

다만 Redux thunk를 이용한 API 비동기 처리와 그 이후 데이터 핸들링을 주 목적으로 Redux를 사용하고 있었다면, 아주 좋은 대안으로 리액트 쿼리를 사용할 수 있습니다.

Redux case

// User.jsx
const [loading, setLoading] = useState(false)

const setUser = async () => {
  try {
    changeToken(token)
    const user = await dispatch(getUsersMeThunk()).unwrap()
  } catch (err) {
    throw new Error(err)
  }
}

useEffect(() => {
  setUser()
}, [])

return <>{loading && <div>로그인 성공!</div>}</>

// Redux thunk middleware
export const getUsersThunk = createAsyncThunk('user/getUserState', async () => {
  try {
    const { data } = await get_user()
    return data
  } catch (err) {
    throw new Error(err)
  }
})

export const userSlice = createSlice({
  name: 'User Info',
  initialState: {
    info: {
      birthdate: '',
      email: '',
      gender: '',
      id: 0,
    },
  },
  reducers: {},
  extraReducers: {
    [getUsersThunk.fulfilled.type]: (state, { payload }) => {
      state.info = { ...payload }
    },
  },
})

React query case

const { isLoading, data, error } = useQuery({
  queryKey: ['user'],
  queryFn: get_user,
})

return <>{data && <div>로그인 성공!</div>}</>

다만 이경우는 단적인 예시일 뿐이며, 다양한 경우를 고려하여 잘 조합해 사용해야 합니다.


Written by@JeongYeonJae
이것저것 쓰는 개발블로그

ResumeGitHub